package net.apexes.codegen.core;

import com.querydsl.codegen.CodegenModule;
import com.querydsl.codegen.EntityType;
import com.querydsl.codegen.Property;
import com.querydsl.codegen.QueryTypeFactory;
import com.querydsl.codegen.Serializer;
import com.querydsl.codegen.SimpleSerializerConfig;
import com.querydsl.codegen.Supertype;
import com.querydsl.codegen.TypeMappings;
import com.querydsl.codegen.utils.CodeWriter;
import com.querydsl.codegen.utils.JavaWriter;
import com.querydsl.codegen.utils.model.ClassType;
import com.querydsl.codegen.utils.model.SimpleType;
import com.querydsl.codegen.utils.model.Type;
import com.querydsl.codegen.utils.model.TypeCategory;
import com.querydsl.sql.ColumnImpl;
import com.querydsl.sql.ColumnMetadata;
import com.querydsl.sql.Configuration;
import com.querydsl.sql.SQLTemplates;
import com.querydsl.sql.SQLTemplatesRegistry;
import com.querydsl.sql.SchemaAndTable;
import com.querydsl.sql.codegen.NamingStrategy;
import com.querydsl.sql.codegen.SQLCodegenModule;
import com.querydsl.sql.codegen.support.ForeignKeyData;
import com.querydsl.sql.codegen.support.InverseForeignKeyData;
import com.querydsl.sql.codegen.support.NotNullImpl;
import com.querydsl.sql.codegen.support.PrimaryKeyData;
import com.querydsl.sql.codegen.support.SizeImpl;
import net.apexes.commons.lang.Checks;
import net.apexes.commons.lang.Enume;
import net.apexes.commons.querydsl.info.ColumnInfo;
import net.apexes.commons.querydsl.info.ForeignKeyInfo;
import net.apexes.commons.querydsl.info.IndexInfo;
import net.apexes.commons.querydsl.info.PrimaryKeyInfo;
import net.apexes.commons.querydsl.info.TableInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.StringWriter;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

/**
 * @author hedyn
 */
public class CodegenExporter {

    private static final Logger LOG = LoggerFactory.getLogger(CodegenExporter.class);

    private final SQLTemplatesRegistry sqlTemplatesRegistry = new SQLTemplatesRegistry();
    private final SQLCodegenModule module = new SQLCodegenModule();
    private final TypeMappings typeMappings = module.get(TypeMappings.class);
    private final Set<Path> alreadyClasses = new HashSet<>();
    private final Map<EntityType, Type> entityToWrapped = new HashMap<>();
    private NamingStrategy namingStrategy;
    private QueryTypeFactory queryTypeFactory;
    private Configuration configuration;
    private Supertype javabeanSupertype;
    private JavabeanSerializer javabeanSerializer;
    private String javabeanPackageName;
    private Path javabeanTargetFolder;
    private Path metadataTargetFolder;
    private KeyDataConvertor keyDataConvertor;
    private String schemaPattern;
    private String tableNamePattern;
    private boolean exportForeignKeys = true;
    private boolean exportInverseForeignKeys = true;
    private boolean exportIndexs = true;
    private boolean lowerCase = false;
    private String sourceEncoding = StandardCharsets.UTF_8.name();
    private boolean columnAnnotations = false;
    private boolean validationAnnotations = false;

    public CodegenExporter() {
        setNamingStrategy(new BasicNamingStrategy());
    }

    public void export(DatabaseMetaData md) throws Exception {
        export(md, null);
    }

    public void export(DatabaseMetaData md, TableFilter tableFilter) throws Exception {
        javabeanPackageName = module.get(String.class, SQLCodegenModule.BEAN_PACKAGE_NAME);
        if (javabeanPackageName == null) {
            javabeanPackageName = module.getPackageName();
        }
        alreadyClasses.clear();
        queryTypeFactory = module.get(QueryTypeFactory.class);
        namingStrategy = module.get(NamingStrategy.class);
        configuration = module.get(Configuration.class);

        SQLTemplates templates = sqlTemplatesRegistry.getTemplates(md);
        if (templates != null) {
            configuration.setTemplates(templates);
        } else {
            LOG.info("Found no specific dialect for " + md.getDatabaseProductName());
        }

        keyDataConvertor = new KeyDataConvertor(namingStrategy, module.getPackageName(), module.getPrefix(), module.getSuffix());

        List<String> schemaPatterns = Collections.singletonList(schemaPattern);
        if (schemaPattern != null && schemaPattern.contains(",")) {
            schemaPatterns = Arrays.asList(schemaPattern.split(","));
        }
        List<String> tablePatterns = Collections.singletonList(tableNamePattern);
        if (tableNamePattern != null && tableNamePattern.contains(",")) {
            tablePatterns = Arrays.asList(tableNamePattern.split(","));
        }

        for (String schemaPattern : schemaPatterns) {
            schemaPattern = schemaPattern != null ? schemaPattern.trim() : null;
            for (String tablePattern : tablePatterns) {
                tablePattern = tablePattern != null ? tablePattern.trim() : null;
                try (ResultSet tables = md.getTables(null, schemaPattern, tablePattern, null)) {
                    while (tables.next()) {
                        String catalog = tables.getString("TABLE_CAT");
                        String schema = tables.getString("TABLE_SCHEM");
                        String tableName = normalize(tables.getString("TABLE_NAME"));
                        if (tableFilter != null) {
                            if (tableFilter.accept(catalog, schema, tableName)) {
                                handleTable(md, catalog, schema, tableName);
                            }
                        } else {
                            handleTable(md, catalog, schema, tableName);
                        }
                    }
                }
            }
        }
    }

    private void handleTable(DatabaseMetaData md, String catalog, String schema, String tableName) throws Exception {
        LOG.info("{}.{} ...", schema, tableName);

        String schemaName = normalize(schema);
        String normalizedSchemaName = namingStrategy.normalizeSchemaName(schemaName);
        String normalizedTableName = namingStrategy.normalizeTableName(tableName);

        SchemaAndTable schemaAndTable = new SchemaAndTable(normalizedSchemaName, normalizedTableName);

        if (!namingStrategy.shouldGenerateClass(schemaAndTable)) {
            return;
        }

        String className = namingStrategy.getClassName(schemaAndTable);
        EntityType classModel = createEntityType(schemaAndTable, className);

        TableInfo tableInfo = new TableInfo(md, catalog, schema, tableName, lowerCase);
        PrimaryKeyInfo pkInfo = tableInfo.getPrimaryKey();
        if (pkInfo != null) {
            PrimaryKeyData pkData = new PrimaryKeyData(pkInfo.getName(), pkInfo.getColumns().toArray(new String[0]));
            classModel.getData().put(PrimaryKeyData.class, pkData);
        }
        if (exportForeignKeys) {
            List<ForeignKeyInfo> fkInfos = tableInfo.getImportedKeys();
            if (Checks.isNotEmpty(fkInfos)) {
                List<ForeignKeyData> foreignKeyDataList = new ArrayList<>();
                Collection<ForeignKeyData> fkDatas = keyDataConvertor.toForeignKeyData(fkInfos);
                for (ForeignKeyData fkd : fkDatas) {
                    if (namingStrategy.shouldGenerateForeignKey(schemaAndTable, fkd)) {
                        foreignKeyDataList.add(fkd);
                    }
                }
                if (!foreignKeyDataList.isEmpty()) {
                    classModel.getData().put(ForeignKeyData.class, foreignKeyDataList);
                }
            }
        }
        if (exportInverseForeignKeys) {
            List<ForeignKeyInfo> invFkInfos = tableInfo.getExportedKeys();
            if (Checks.isNotEmpty(invFkInfos)) {
                classModel.getData().put(InverseForeignKeyData.class, keyDataConvertor.toInverseForeignKeyData(invFkInfos));
            }
        }
        if (exportIndexs) {
            List<IndexInfo> ixInfos = tableInfo.getIndexs();
            if (Checks.isNotEmpty(ixInfos)) {
                classModel.getData().put(net.apexes.codegen.core.IndexData.class, keyDataConvertor.toIndexData(ixInfos));
            }
        }

        // collect columns
        for (ColumnInfo columnInfo : tableInfo.getColumns()) {
            handleColumn(classModel, tableName, columnInfo);
        }

        // serialize model
        serialize(classModel);

        LOG.info("{}.{} successfully!", schema, tableName);
    }


    @SuppressWarnings("unchecked")
    protected EntityType createEntityType(SchemaAndTable schemaAndTable, final String className) {
        String beanPackage = javabeanPackageName;
        String simpleName = module.getBeanPrefix() + className + module.getBeanSuffix();
        String fullName = beanPackage + "." + simpleName;
        Type classTypeModel = new SimpleType(TypeCategory.ENTITY, fullName, beanPackage, simpleName, false, false);
        EntityType classModel = new OrderableEntityType(classTypeModel, module.get(Function.class, CodegenModule.VARIABLE_NAME_FUNCTION_CLASS));

        Type mappedType = queryTypeFactory.create(classModel);
        entityToWrapped.put(classModel, mappedType);
        typeMappings.register(classModel, mappedType);

        classModel.getData().put("schema", schemaAndTable.getSchema());
        classModel.getData().put("table", schemaAndTable.getTable());

        return classModel;
    }

    private void handleColumn(EntityType classModel, String tableName, ColumnInfo columnInfo) throws SQLException {
        String columnName = columnInfo.getName();
        String normalizedColumnName = columnInfo.getNormalizedName();
        int columnType = columnInfo.getJdbcType();
        String typeName = columnInfo.getTypeName();
        Number columnSize = columnInfo.getSize();
        Number columnDigits = columnInfo.getDigits();
        int columnIndex = columnInfo.getIndex();
        String defaultValue = columnInfo.getDefaultValue();
        String describe = columnInfo.getDescribe();

        ColumnMetadata column = ColumnMetadata.named(normalizedColumnName).ofType(columnType).withIndex(columnIndex);
        if (columnInfo.isNotNullable()) {
            column = column.notNull();
        }
        if (columnSize != null && columnSize.intValue() > 0) {
            column = column.withSize(columnSize.intValue());
        }
        if (columnDigits != null) {
            column = column.withDigits(columnDigits.intValue());
        }

        String propertyName = namingStrategy.getPropertyName(normalizedColumnName, classModel);
        Class<?> clazz = configuration.getJavaType(columnType,
                typeName,
                columnSize != null ? columnSize.intValue() : 0,
                columnDigits != null ? columnDigits.intValue() : 0,
                tableName, columnName);
        if (clazz == null) {
            clazz = Object.class;
        }
        TypeCategory fieldType = TypeCategory.get(clazz.getName());
        if (Number.class.isAssignableFrom(clazz)) {
            fieldType = TypeCategory.NUMERIC;
        } else if (Enum.class.isAssignableFrom(clazz)) {
            fieldType = TypeCategory.ENUM;
        }
        Type typeModel = new ClassType(fieldType, clazz);
        Property property = createProperty(classModel, normalizedColumnName, propertyName, typeModel);
        property.getData().put("COLUMN", column);
        if (defaultValue != null) {
            property.getData().put("COLUMN_DEF", defaultValue);
        }
        if (describe != null && !describe.trim().isEmpty()) {
            property.getData().put("REMARKS", describe);
        }

        if (columnAnnotations) {
            property.addAnnotation(new ColumnImpl(normalizedColumnName));
        }
        if (validationAnnotations) {
            if (columnInfo.isNotNullable()) {
                property.addAnnotation(new NotNullImpl());
            }
            int size = (columnSize == null ? 0 : columnSize.intValue());
            if (size > 0 && clazz.equals(String.class)) {
                property.addAnnotation(new SizeImpl(0, size));
            }
        }
        classModel.addProperty(property);
    }

    protected Property createProperty(EntityType classModel, String normalizedColumnName, String propertyName, Type typeModel) {
        return new Property(classModel, propertyName, propertyName, typeModel, Collections.emptyList(), false);
    }

    private void serialize(EntityType model) throws Exception {
        if (javabeanSerializer != null) {
            if (javabeanSupertype != null) {
                model.addSupertype(javabeanSupertype);
            }
            String path = javabeanPackageName.replace('.', '/') + "/" + model.getSimpleName() + ".java";
            write(javabeanSerializer, javabeanTargetFolder.resolve(path), model);
        }

        if (metadataTargetFolder != null) {
            Serializer metadataSerializer = module.get(Serializer.class);
            String path = entityToWrapped.get(model).getFullName().replace('.', '/') + ".java";
            write(metadataSerializer, metadataTargetFolder.resolve(path), model);
        }
    }

    private void write(Serializer serializer, Path targetFile, EntityType model) throws IOException {
        if (!alreadyClasses.add(targetFile)) {
            throw new IllegalStateException("Attempted to write multiple times to "
                    + targetFile + ", please check your configuration");
        }
        StringWriter w = new StringWriter();
        CodeWriter writer = new JavaWriter(w);
        serializer.serialize(model, SimpleSerializerConfig.DEFAULT, writer);

        // conditional creation
        boolean generate = true;
        byte[] bytes = w.toString().getBytes(sourceEncoding);
        if (Files.exists(targetFile) && Files.size(targetFile) == bytes.length) {
            String str = new String(Files.readAllBytes(targetFile), Charset.forName(sourceEncoding));
            if (str.equals(w.toString())) {
                generate = false;
            }
        } else {
            Files.createDirectories(targetFile.getParent());
        }

        if (generate) {
            Files.write(targetFile, bytes);
        }
    }

    private String normalize(String str) {
        if (lowerCase && str != null) {
            return str.toLowerCase();
        } else {
            return str;
        }
    }

    public void registerEnumes(Collection<Class<? extends Enume<?>>> enumeClasses, Class<?> pathClass) {
        for (Class<? extends Enume<?>> enumeClass : enumeClasses) {
            ClassType enumeType = new ClassType(TypeCategory.CUSTOM, enumeClass);
            ClassType queryType = new ClassType(TypeCategory.SIMPLE, pathClass, enumeType);
            typeMappings.register(enumeType, queryType);
        }
    }

    public void setJavabeanSupertype(Class<?> classType) {
        if (classType != null) {
            this.javabeanSupertype = new SuperType(new ClassType(TypeCategory.ENTITY, classType));
        }
    }

    public void setMetadataSupertype(Class<?> classType) {
        module.bindInstance(SQLCodegenModule.ENTITYPATH_TYPE, classType);
    }

    public void setJavabeanSerializer(JavabeanSerializer javabeanSerializer) {
        this.javabeanSerializer = javabeanSerializer;
    }

    public <T extends Serializer> void setMetadataSerializerClass(Class<T> serializerClass) {
        module.bind(Serializer.class, serializerClass);
    }

    public void setJavabeanTargetFolder(Path targetFolder) {
        this.javabeanTargetFolder = targetFolder;
    }

    public void setMetadataTargetFolder(Path targetFolder) {
        this.metadataTargetFolder = targetFolder;
    }

    public void setMetadataPackageName(String packageName) {
        module.bind(SQLCodegenModule.PACKAGE_NAME, packageName);
    }

    public void setJavabeanPackageName(String packageName) {
        module.bind(SQLCodegenModule.BEAN_PACKAGE_NAME, packageName);
    }

    public void setMetadataNamePrefix(String namePrefix) {
        module.bind(CodegenModule.PREFIX, namePrefix);
    }

    public void setMetadataNameSuffix(String nameSuffix) {
        module.bind(CodegenModule.SUFFIX, nameSuffix);
    }

    public void setJavabeanPrefix(String beanPrefix) {
        module.bind(SQLCodegenModule.BEAN_PREFIX, beanPrefix);
    }

    public void setJavabeanSuffix(String beanSuffix) {
        module.bind(SQLCodegenModule.BEAN_SUFFIX, beanSuffix);
    }

    public void setNamingStrategy(NamingStrategy namingStrategy) {
        module.bind(NamingStrategy.class, namingStrategy);
    }

    public <T extends JdbcTypeConverter> void setJdbcTypeConverter(T implementation) {
        module.bind(JdbcTypeConverter.class, implementation);
    }

    public void setColumnComparatorClass(Class<? extends Comparator<Property>> columnComparatorClass) {
        module.bind(SQLCodegenModule.COLUMN_COMPARATOR, columnComparatorClass);
    }

    public void setColumnAnnotations(boolean columnAnnotations) {
        this.columnAnnotations = columnAnnotations;
    }

    public void setValidationAnnotations(boolean validationAnnotations) {
        this.validationAnnotations = validationAnnotations;
    }

    public void setSchemaPattern(String schemaPattern) {
        this.schemaPattern = schemaPattern;
    }

    public void setTableNamePattern(String tableNamePattern) {
        this.tableNamePattern = tableNamePattern;
    }

    public void setConfiguration(Configuration configuration) {
        module.bind(Configuration.class, configuration);
    }

    public void setSourceEncoding(String sourceEncoding) {
        this.sourceEncoding = sourceEncoding;
    }

    public void setLowerCase(boolean lowerCase) {
        this.lowerCase = lowerCase;
    }

    public void setInnerClassesForKeys(boolean innerClassesForKeys) {
        module.bind(SQLCodegenModule.INNER_CLASSES_FOR_KEYS, innerClassesForKeys);
    }

    public void setExportForeignKeys(boolean exportForeignKeys) {
        this.exportForeignKeys = exportForeignKeys;
    }

    public void setExportInverseForeignKeys(boolean exportInverseForeignKeys) {
        this.exportInverseForeignKeys = exportInverseForeignKeys;
    }

    public void setExportIndexs(boolean exportIndexs) {
        this.exportIndexs = exportIndexs;
    }

    public void setImports(String[] imports) {
        module.bind(CodegenModule.IMPORTS, new HashSet<String>(Arrays.asList(imports)));
    }

}
